package pl.qsoft.db.storedProc.engine;

import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.sql.CallableStatement;
import java.sql.Connection;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

import javax.naming.InitialContext;
import javax.sql.DataSource;

import pl.qsoft.db.annotation.StoredProcedure;
import pl.qsoft.db.annotation.StoredProcedureDefinition;
import pl.qsoft.db.annotation.StoredProcedureParam;
import pl.qsoft.db.annotation.StoredProcedure.CachePolicy;
import pl.qsoft.db.exception.StoredProcedureDefinitionException;
import pl.qsoft.db.exception.StoredProcedureInvocationException;
import pl.qsoft.db.storedProc.cache.CacheManager;
import pl.qsoft.db.storedProc.cache.CacheUtils;
import pl.qsoft.db.storedProc.intercept.InvocationInterceptor;
import pl.qsoft.db.storedProc.intercept.ProcedureInvocationContext;
import pl.qsoft.db.storedProc.statement.ResultTransformer;
import pl.qsoft.db.storedProc.statement.StatementBuilder;
import pl.qsoft.db.storedProc.statement.StatementBuilderFactory;
import pl.qsoft.db.storedProc.statement.StoredProcedureStatement;

import sun.reflect.misc.MethodUtil;

/**
 * Basic implementation of a InvocationEngine interface. It is responsible for automation of the whole process of interpreting stored procedure params, creating connection, etc.
 * @author Wojciech Kudla
 * @see ProcedureInvocationEngine
 */
public class GenericInvocationEngine implements ProcedureInvocationEngine {

	/**
	 * Number of stackTrace elements to be looked up in the stacktrace during the invocation for finding a calling method that is annotated with <code>@StoredProcedure</code> annotation
	 */
	protected int INVOCATION_LEVEL = 5;
	

	/**
	 * Main method that is accesible for the component client
	 * @param params Stored procedure parameter values 
	 */
	public Object invoke(Object... params) {
		Method method = getInvokingMethod(params);
		StoredProcedure proc = method.getAnnotation(StoredProcedure.class);
		StoredProcedureDefinition procDef = (StoredProcedureDefinition) proc.procedureDefinition().getAnnotation(StoredProcedureDefinition.class);
		StoredProcedureParam[] paramDefs = collectStoredProcedureParams(proc);
		Map values = identifyInputParams(method, params);
		InvocationInterceptor interceptor = getInvocationInterceptor(proc);
		ProcedureInvocationContext invocationContext = new ProcedureInvocationContext(procDef, proc, values);
		interceptor.beforeInvoke(invocationContext);
		Object result = null;
		if (!proc.cachePolicy().equals(CachePolicy.DISABLED)) {
			result = CacheManager.getResultFromCache(procDef, values);
			if (result != null) {
				interceptor.afterInvoke(invocationContext);
				if (proc.cachePolicy().equals(CachePolicy.CLONED))
					result = CacheUtils.deepCopy(result);
				return result;
			}
		}
		StatementBuilder stmtBuilder = StatementBuilderFactory.getStatementBuilder(procDef.dataBaseVendor());
		Connection conn = null;
		CallableStatement stmt = null;
		try {
			conn = getConnection(procDef);
			String stmtString = buildStatementString(procDef, paramDefs.length);
			stmt = new StoredProcedureStatement(conn.prepareCall(stmtString));
			stmt = stmtBuilder.buildStatement(stmt, procDef, paramDefs, values);
			stmt.execute();
			interceptor.afterInvoke(invocationContext);
			result = tranformResult(stmt, procDef, values);
		} catch (Exception e) {
			throw new StoredProcedureInvocationException("Exception handling stored procedure invocation", e);
		} finally {
			releaseResources((StoredProcedureStatement) stmt, conn);
		}
		if (!proc.cachePolicy().equals(CachePolicy.DISABLED))
			CacheManager.putResultToCache(procDef, values, result);
		return result;
	}
	
	
	/**
	 * Releases all reserved resources - opened result sets, statements and connections
	 * @param stmt Stored procedure statement (containing result sets) to be closed
	 * @param conn Database connection to be closed 
	 */
	protected void releaseResources(StoredProcedureStatement stmt, Connection conn) {
		try {
			Set<ResultSet> results = stmt.getOpenedResultSets();
			for (ResultSet rs : results) if (rs != null) rs.close();
			stmt.close(); conn.close();
		} catch (Exception e) {
			throw new StoredProcedureInvocationException("Problem releasing resources", e);
		}		
	}
	
	
	/**
	 * Transforms executed statement containing values to the desired java objects 
	 * @param stmt Statement that has been executed
	 * @param procDef Stored procedure definition
	 * @param values Stored procedure parameter names/indexes with values
	 * @return Transformed java object
	 */
	protected Object tranformResult(CallableStatement stmt, StoredProcedureDefinition procDef, Map values) {
		Object result = null;
		try {
			ResultTransformer transformer = (ResultTransformer) CacheManager.getClassFromCache(procDef.resultTransformer()); 
			if (transformer == null) {
				transformer = (ResultTransformer) procDef.resultTransformer().newInstance();
				CacheManager.putClassToCache(transformer);
			}
			result = transformer.transform(stmt, procDef, values);
		} catch (Exception e) {
			throw new StoredProcedureInvocationException("Exception transforming result", e);
		} 
		return result;
	}
	
	
	/**
	 * Obtains defined InvocationInterceptor
	 * @param proc Stored procedure that defines the interceptor
	 * @return Invocation interceptor
	 * @see InvocationInterceptor
	 */
	protected InvocationInterceptor getInvocationInterceptor(StoredProcedure proc) {
		InvocationInterceptor interceptor = (InvocationInterceptor) CacheManager.getClassFromCache(proc.invocationInterceptor());
		if (interceptor == null) {
			try {
				interceptor = (InvocationInterceptor) proc.invocationInterceptor().newInstance();
				CacheManager.putClassToCache(interceptor);
			} catch (Exception e) {
				throw new StoredProcedureInvocationException("Exception obtaining interceptor for stored procedure", e);
			}
		}
		return interceptor;
	}
	
	
	/**
	 * Creates a map of name/index -> value pairs that binds each parameter value with its identity
	 * @param method Calling method annotated with the <code>@StoredProcedure</code> annotation
	 * @param values Values passed to the calling method
	 * @return Map of name/index -> value pairs
	 */
	private Map identifyInputParams(Method method, Object... values) {
		Map result = new HashMap();
		Annotation[][] annotations = method.getParameterAnnotations();
		for (int p = 0; p < annotations.length; p++) {
			for (int a = 0; a < annotations[p].length; a++) {
				StoredProcedureParam param = (StoredProcedureParam) annotations[p][a];
				if (param != null) {
					if (!param.paramName().equals("")) result.put(param.paramName(), values[p]);
					if (param.paramNumber() > -1 ) result.put(param.paramNumber(), values[p]);
				}
			}
		}
		return result;
	}

	
	/**
	 * Collects stored procedure parameter annotation from the class defining the stored procedure itself
	 * @param proc Stored procedure
	 * @return An array of stored procedure parameter descriptions
	 */
	private StoredProcedureParam[] collectStoredProcedureParams(StoredProcedure proc) {
		Field[] fields = proc.procedureDefinition().getDeclaredFields();
		List<StoredProcedureParam> paramList = new ArrayList<StoredProcedureParam>();
		for (int i = 0; i < fields.length; i++) {
			StoredProcedureParam param = fields[i].getAnnotation(StoredProcedureParam.class);
			if (param != null) paramList.add(param);
		}
		StoredProcedureParam[] result = new StoredProcedureParam[paramList.size()];
		return paramList.toArray(result);
	}


	/**
	 * Builds statement SQL string
	 * @param proc Stored procedure definition
	 * @param length Number of parameters
	 * @return SQL statement string
	 */
	protected String buildStatementString(StoredProcedureDefinition proc, int length) {
		String result = "{call " + proc.procedureName() + "(";
		for (int i = 0; i < length; i++) result += i != length - 1 ? "?," : "?)}";
		return result;
	}
	
	
	//TODO: move this logic to a separate class with defined interface
	/**
	 * Does a connection lookup in the JNDI
	 * @param proc Stored procedure definition
	 * @return Connection object for the given datasource path
	 */
	protected Connection getConnection(StoredProcedureDefinition proc) {
		Connection connection = null;
		try {
			InitialContext ctx = new InitialContext();
			DataSource ds = (DataSource) ctx.lookup(proc.dataSource());
			connection = ds.getConnection();
		} catch (Exception e) {
			throw new StoredProcedureDefinitionException("Cannot obtain connection from datasource defined in dataSource property", e);
		}
		return connection;
	}
	
	
	/**
	 * Retrieves calling method object for further processing (obtaining additional information from its annotation and annotation of its parameters)
	 * @param params Method's input parameter values
	 * @return Method object
	 */
	protected Method getInvokingMethod(Object... params) {
		Method method = null;
		try {
			String methodName = getInvokingElement().getMethodName();
			Class clazz = Class.forName(getInvokingElement().getClassName());
			Method[] methods = MethodUtil.getMethods(clazz);
			for (Method m : methods) {
				if (m.getName().equals(methodName) && m.isAnnotationPresent(StoredProcedure.class)) {
					method = m;
					break;
				}
			}
		} catch (ClassNotFoundException e) {
			throw new StoredProcedureInvocationException("Exception finding calling class", e);
		}
		return method;		
	}
	
	
	/**
	 * Returns a stracktrace element given a specific element level 
	 * @return Stacktrace element
	 */
	protected StackTraceElement getInvokingElement() {
		StackTraceElement[] elements = Thread.currentThread().getStackTrace(); 
		if (elements.length <= 0) return null;
		if (elements.length < INVOCATION_LEVEL) return null;		  
		return elements[INVOCATION_LEVEL];
	}

}
